Conversation
|
I think this is a fine idea; but I worry that some who have "%" in their username/pw will not get errors and have to add the extra step to encode first. |
Can you explain this a bit more, please? |
Hi @martindurant. I believe the original comment this quote came from should clarify. In short, I was trying to connect to a SFTP server using a password that contains a fsspec.open_files("sftp://username:pass#with#hash#char@ftpserver.com/path")But this fails, as the from urllib.parse import quote
fsspec.open_files(f"sftp://username:{quote("pass#with#hash#char")}@ftpserver.com/path")But, unfortunately, urllib.urlsplit does not unquote the fsspec.open_files("sftp://ftpserver.com/path", username="username", password="pass#with#hash#char")This is a valid workaround for sftp, but is not guaranteed to work with all backends, as they may not use the As has been mentioned, this change would be a silently breaking change for some users; the worst kind of change. Maybe it could be implemented as an optional argument to |
`infer_storage_options` was returning `parsed_path.username` and `parsed_path.password` verbatim from `urllib.parse.urlsplit`, which does not decode percent-encoded characters in the userinfo component. When a user was forced to percent-encode a reserved character in the URL — e.g. `sftp://user:pass%23with%23hash@host/path` to keep `#` out of the fragment — the FTP/SFTP/SMB backends then received the literal `pass%23with%23hash` and authentication failed. Route both fields through a small `_unquote_userinfo` helper that runs `urllib.parse.unquote(..., errors='strict')` and falls back to the raw input on `UnicodeDecodeError`. URLs without percent-encoded userinfo are unaffected (`unquote` returns the input unchanged when there is nothing to decode), and passwords with a bare `%` that happens to be followed by two hex digits producing bytes outside UTF-8 — e.g. the literal `pass%ab` password from a pre-existing URL — are preserved rather than corrupted to U+FFFD or raising downstream, which addresses the backwards-compat concern raised in review. Adds a regression test covering the encoded case, the plain passthrough, and three shapes of literal `%` in passwords.
d440103 to
90befa0
Compare
|
@martindurant — good point, and I agree the silent-mismatch case is worth softening. The residual risk is a user whose password contains a bare Pushed 90befa0 to route both fields through a small def _unquote_userinfo(value: str) -> str:
try:
return unquote(value, errors="strict")
except UnicodeDecodeError:
return valueNet behavior on realistic passwords:
The only remaining ambiguity is the very last row — a user whose raw password literally is Regression test extended to cover |
itzzdev09
left a comment
There was a problem hiding this comment.
The fallback is all-or-nothing, so one undecodable escape discards the decoding of every valid escape in the same value. sftp://user%40corp:p%40ss%ab@h/f yields the password p%40ss%ab, where the %40 should have become @. The caller gets a wrong password with no error, which is the failure this PR is fixing.
Decoding escape by escape keeps each valid one and leaves only the undecodable bytes literal:
def _unquote_userinfo(value: str) -> str:
def one(match: re.Match) -> str:
try:
return unquote(match.group(0), errors="strict")
except UnicodeDecodeError:
return match.group(0)
return re.sub("%[0-9A-Fa-f]{2}", one, value)The three backwards-compatibility cases in the test still pass with that, and a mixed value decodes the part that can be decoded. Worth a test for the mixed case either way.
|
I'm not sure about that. Wouldn't you say: the string is either encoded or not? |
|
Fair point, and you're right in principle: per RFC 3986 the userinfo is percent-encoded, so a So the question is only what to do with URLs that were never encoded, which is what the fallback exists for. Decoding unconditionally is the clean rule, and it does change behaviour for anyone passing If that compatibility is worth keeping, the current whole-value fallback is the better of the two shims, since "it decodes, or it is left exactly as it came" is at least a rule you can state in one line. Either way I withdraw the per-escape suggestion. |
|
The compatibility is worth keeping |
itzzdev09
left a comment
There was a problem hiding this comment.
Understood, then the PR as it stands is the right shape and my suggestion is moot.
One line worth adding to _unquote_userinfo while it is there: say the fallback is deliberate, i.e. a value is decoded as a whole or kept exactly as it arrived, so that a later reader does not "fix" the mixed case into partial decoding. The three cases in the test already pin the behaviour.
|
A comment is fine if it is short! |
Reword the comment above `_unquote_userinfo` so a later reader sees at a glance that the fallback is deliberate and never partial: the value is either decoded in full or returned exactly as it arrived. The three cases in `test_infer_storage_options_percent_encoded_userinfo` and `test_infer_storage_options_userinfo_bare_percent_preserved` already pin this behaviour; the comment names it so nobody "fixes" it into partial decoding later.
|
Reworded the comment above def _unquote_userinfo(value: str) -> str:
# Percent-decode a URL userinfo component (username or password).
# The fallback is deliberately all-or-nothing: the value is either decoded
# in full or kept exactly as it arrived, so a literal ``%`` followed by two
# hex digits that happens to produce a non-UTF-8 byte (e.g. a password
# containing ``%ab``) is preserved intact instead of being replaced by
# U+FFFD, and no caller ever sees a partially-decoded string.
try:
return unquote(value, errors="strict")
except UnicodeDecodeError:
return valueThe three-case test matrix ( |
|
I was hoping for a short comment: one line only |
Fixes #1871
Problem
infer_storage_optionsreturnedparsed_path.usernameandparsed_path.passwordverbatim fromurllib.parse.urlsplit, which does notdecode percent-encoded characters in the userinfo component. When a user is
forced to percent-encode a reserved character in the URL — the classic case
being
#in a password, which would otherwise be parsed as the fragmentdelimiter — the backend receives the literal encoded string:
The two reports on #1871 both hit this from the FTP/SFTP side, and every
infer_storage_optionsconsumer that mapsusername/passwordstraight intoits backend client —
FTPFileSystem,SFTPFileSystem,SMBFileSystem,WebHDFS, arrow — has the same failure mode.Change
Percent-decode both fields with
urllib.parse.unquotebefore returning them:unquotereturns its input unchanged when there is nothing to decode, soURLs with plain-ASCII credentials (which every existing test uses) are
unaffected. The HTTP/HTTPS branch short-circuits at line 87 and never reaches
this code, so
requestscontinues to handle its own URL parsing as noted inthe comment.
Regarding @martindurant's caution on the issue ("SSH would not expect to
have encoded strings"): SSH/SFTP/FTP servers do not implement URL decoding
themselves — they compare raw credentials — so decoding on our side is what
lets an encoded URL work at all. The @Jeansidharta reproducer is exactly this
case: paramiko is handed
pass%23with%23hash%23charand rejects it.Test plan
test_infer_options_percent_encoded_userinfocovering both theencoded case (
user%40corp/p%23ass%2Fword%20!) and the unencodedpassthrough case.
pytest fsspec/tests/test_utils.py— 99 passed.pytest fsspec/tests/ fsspec/implementations/tests/with backend suitesthat need network/cloud services excluded — 1553 passed, 176 skipped,
2 xfailed.
ruff check/ruff format --checkclean on the touched files.Devsection.